20 优惠券如何避免超兑——引入分布式锁

会员办理月卡或签到累积的积分,可以在指定时间段内兑换商场优惠券,由于数量有限,时间有限,兑换操作相当集中,如果按正常流程处理的话,肯定会出现超兑的情况。比如只有 5000 张券,结果兑换出 8000 张,这对商场来说是一笔经济损失。

为防止超兑,自然做法是按总量一个接一个兑换,至到兑换完,但多并发的情况下如何保证还一个一个兑换呢?自然而然就会想到锁上面来。提及锁,你脑海是不是出现了一堆关于锁的场景:死锁、互斥锁、乐观锁、悲观锁等等,本节介绍分布式锁,它主要应用于分布式系统下面,单体应用基本不会涉及。

两种实现机制介绍

常见的实现方法分布式锁可以基于数据库、Redis、Zookeeper 等第三方工具来实现,各种不同实现方式需要引入第三方,截止目前 MySQL 及 Redis 已经引入到实战中,为降低系统复杂度,我们想办法基于这两个机制进行分布式锁实现。

  1. 采用数据库实现分布式锁,还记得前面《分布式定时任务》章节吗?里面就用到分布式锁。为保证指定时刻下多实例定时任务的执行,优先通过 ShedLock 的方式获取锁,锁产生在公共存储库中,生成一条新记录来告诉其它集群中其它实例,我正在执行,其它实例获取到这个状态后,自动跳过不再执行,来保证同一时刻只有一个任务在执行。
  2. 采用 Redis 实现分布式锁。Redis 提供了 setnx 指令,保证同一时刻内只有一个请求针对同一 key 进行 setnx 操作,鉴于 Redis 是单线程模式,依旧是先到先得,晚到不得,通过这个操作可以实现排它性的操作。

但此做法存在漏洞,操作 key 后,指令发起方挂掉的话,这个 key 就永远不能被操作了。稍做改进,给 key 设置失效时间,这样就可以到期自动释放,供其它操作。但依旧有漏洞,在 setnx 后,发起 expire 前服务挂了,这种方式依旧与第一处方式类似。

细查 Redis 官方指令后,发现 set 指令后还跟有 [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] 等选项,可以针对第二种方式用此方式进一步改进。在单实例 Redis 的情况下,在实例可用的情况下,取锁、释放锁操作已经基本可用。

Redis 另外提供了一种 Redlock 算法来实现分面式锁,有兴趣的朋友可看原文中文版本),在单实例无法保证可用的情况下,通过集群中多实例来有效防止单点故障导致锁不可用。大致意思是同某一时刻,向所有实例发起加锁请求,如果获取到 N/2+1 个锁表示成功,否则失败并自动解锁所有实例,到达锁失效期后同样去解锁所有实例。

如果是自己去实现这一套算法的话,想必还是比较复杂的,庆幸的是有非常好的成品,已经帮我们完成了。这就是本篇要提到的 Redission 客户端,里面有 Redlock 分布式锁的完整实现。

什么是 Redisson

Redis 的三大 Java 客户端之一,其它两个是:Jedis 和 Lettuce(SpringBoot 2.x 之后就将默认集成的 Jedis 客户端替换成 Lettuce)。不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。

更多介绍参见官网:redisson

引入 Redisson

由于我们使用的框架是 Spring Boot 搭建的,这里同样采用 starter 的方式引入(不再需要 spring-data-redis 模块):

<dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.11.6</version>
</dependency>

配置文件采用 redis 的默认配置方式,可以兼容:

#redis config
spring.redis.database=2
spring.redis.host=localhost
spring.redis.port=16479
#default redis password is empty
spring.redis.password=zxcvbnm,./
spring.redis.timeout=60000
spring.redis.pool.max-active=1000
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=10
spring.redis.pool.min-idle=5

代码编写、测试

这里编写了一个启动类,将本次兑换优惠券总可兑换数量写入缓存,每次采用原子操作进行减少。

@Component
@Order(0)
public class StartupApplicatonRunner implements ApplicationRunner {

    @Autowired
    Redisson redisson;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        RAtomicLong atomicLong = redisson.getAtomicLong(ParkingConstant.cache.grouponCodeAmtKey);
        atomicLong.set(ParkingConstant.cache.grouponCodeAmt);
    }

}

在兑换逻辑中,判断优惠券可用数量,兑换结束后数量减 1:

    @Autowired
    Redisson redisson;

    @Override
    public int createExchange(String json) throws BusinessException {
        Exchange exchange = JSONObject.parseObject(json, Exchange.class);
        int rtn = 0;
        // 兑换类型有两部分,0 是商场优惠券,1 是洗车券,这是作了简单区分
        if (exchange.getCtype() == 0) {
            RAtomicLong atomicLong = redisson.getAtomicLong(ParkingConstant.cache.grouponCodeAmtKey);
            // 获取锁
            RLock rLock = redisson.getLock(ParkingConstant.lock.exchangeCouponLock);
      // 锁定,默认 10s 不主动解锁的话,自动解锁,防止出现死锁的情况。正常情况下可基于 redisson 获取 redLock 处理,更加安全,本测试基于单机 redis 测试。
            rLock.lock(1000, TimeUnit.SECONDS);
      log.info("lock it when release ...");

            // 判定可兑换数量,如果有就兑换,兑换结束数量减一
            if (atomicLong.get() > 0) {
                rtn = exchangeMapper.insertSelective(exchange);
                atomicLong.decrementAndGet();
            }

            // 释放锁
            rLock.unlock();
            log.info("exchage coupon ended ...");
        } else {
            rtn = exchangeMapper.insertSelective(exchange);
        }
        log.debug("create exchage ok = " + exchange.getId());
        return rtn;
    }

简单测试,将 lock 时间设置个较长时间,利用断点来测试(也可以采用前面介绍到的 Postman 的方式进行并发测试)。

  1. 准备两个实例,一个实例构建成 jar 运行,一个实例在 IDE 中运行。
  2. 在 if 判定处打断点,在 IDE 中启动第一个实例,请求 lock 后,不向下运行。
  3. 启动 jar 实例,发起第二个请求,可以看到日志并未输出 _lock it when release …_,而是一直在等待。
  4. 将 IDE 中的断点跳过,执行结束,自动释放锁。回头看 jar 实例的日志输出,可以看到两个日志正常输出。

这样就达到分布锁的目标,实际应用中锁定时间肯定比较短,否则服务会被拖垮,很类似秒杀的场景,但杀场景更复杂,还需要其它辅助手段,不能如此简单处理。文中只提到 Redission 的这一种锁的用法,文后留个小作业吧,你再研究下 Redission 还有没有其它场景下的用法。